SPB Git

spb/svgarden Public

SVGarden — searchable bank of 74 self-contained SVG+CSS animation snippets (svgarden.dev)

HTML 79.2% Astro 10.3% JavaScript 6% CSS 3.7% Shell 0.8%
7.0 KB · 196 lines astro
Raw Blame History
1---2/**3 * ============================================================4 * SVGarden — https://www.svgarden.dev5 * Author : Simon-Pierre Boucher6 * Contact: contact@spboucher.ai7 * File   : src/pages/snippet/[slug].astro8 * Desc   : Snippet detail — live preview, customizer, lesson, copy-ready code9 * ============================================================10 */11import Base from '../../layouts/Base.astro';12import LivePreview from '../../components/LivePreview.astro';13import CodeBlock from '../../components/CodeBlock.astro';14import Customizer from '../../components/Customizer.astro';15import SnippetCard from '../../components/SnippetCard.astro';16import { loadSnippets, copyText } from '../../lib/snippets.mjs';1718export async function getStaticPaths() {19  const snippets = await loadSnippets();20  return snippets.map((s) => ({ params: { slug: s.slug }, props: { snippet: s } }));21}2223const { snippet } = Astro.props;24const all = await loadSnippets();25const related = all.filter((s) => s.category === snippet.category && s.slug !== snippet.slug).slice(0, 3);26const initialCopy = copyText(snippet);2728// Everything the client-side customizer needs to rewrite preview + code + clipboard.29const island = {30  header: snippet.header,31  rawCode: snippet.code,32  slug: snippet.slug,33  vars: snippet.customizable.map((c) => ({34    name: c.var,35    unit: c.unit ?? '',36    initial: `${c.default}${c.unit ?? ''}`,37  })),38};39---4041<Base title={`${snippet.title} — SVGarden`} description={snippet.desc}>42  <div class="sg-detail-head">43    <p class="sg-breadcrumb">44      <a href="/">Gallery</a> / <a href={`/category/${snippet.category}`}>{snippet.category}</a> / {snippet.title}45    </p>46    <h1>{snippet.title}</h1>47    <p style="color: var(--sg-text-soft); margin: 0;">{snippet.desc}</p>48    <div class="sg-detail-meta">49      <span class="sg-badge sg-badge-cat">{snippet.category}</span>50      <span class={`sg-badge sg-badge-${snippet.difficulty}`}>{snippet.difficulty}</span>51      {snippet.tags.map((t) => <span class="sg-badge">{t}</span>)}52    </div>53  </div>5455  <div class="sg-detail-grid">56    <div>57      <LivePreview snippet={snippet} variant="full" />58      {59        snippet.support && (60          <p class="sg-support-note">61            <strong>⚠ Browser support</strong>62            <span>{snippet.support}</span>63          </p>64        )65      }66      <aside class="sg-how">67        <h2>How it works</h2>68        <p>{snippet.howItWorks}</p>69      </aside>70      {71        snippet.techniques.length > 0 && (72          <p style="color: var(--sg-text-soft); font-size: 0.875rem;">73            Techniques: {snippet.techniques.join(' · ')}74          </p>75        )76      }77    </div>78    <div>79      {snippet.customizable.length > 0 && <Customizer controls={snippet.customizable} />}80    </div>81  </div>8283  <section class="sg-section">84    <CodeBlock code={initialCopy} label={snippet.relPath} downloadName={`${snippet.slug}.html`} />85  </section>8687  {88    related.length > 0 && (89      <section class="sg-section">90        <h2>More {snippet.category}</h2>91        <div class="sg-related">92          {related.map((s) => (93            <SnippetCard snippet={s} />94          ))}95        </div>96      </section>97    )98  }99100  <script type="application/json" id="sg-island" set:html={JSON.stringify(island)} />101</Base>102103<script>104  // ---- Detail-page wiring: customizer ⇆ preview ⇆ code block ⇆ clipboard ----105  const islandEl = document.getElementById('sg-island');106  const frame = document.getElementById('sg-preview-frame') as HTMLIFrameElement | null;107108  // Preview background toggle (light/dark canvas behind the animation)109  for (const btn of document.querySelectorAll<HTMLButtonElement>('[data-sg-bg]')) {110    btn.addEventListener('click', () => {111      frame?.contentDocument?.body?.style.setProperty('--sg-preview-bg', btn.dataset.sgBg!);112      document113        .querySelectorAll('[data-sg-bg]')114        .forEach((b) => b.setAttribute('aria-pressed', String(b === btn)));115    });116  }117118  if (islandEl) {119    const island = JSON.parse(islandEl.textContent!) as {120      header: string;121      rawCode: string;122      slug: string;123      vars: { name: string; unit: string; initial: string }[];124    };125126    const current = new Map(island.vars.map((v) => [v.name, v.initial]));127    const codeEl = document.querySelector('[data-sg-codeblock] pre code');128    const copySource = document.querySelector('[data-sg-copy-source]');129130    const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');131132    // Rebuild the full copy/download text from the raw snippet + current values.133    const rebuildCopyText = () => {134      let code = island.rawCode;135      for (const [name, value] of current) {136        code = code.replace(new RegExp(`(${esc(name)}\\s*:\\s*)[^;"']+`, 'g'), `$1${value}`);137      }138      if (copySource) copySource.textContent = `${island.header}\n${code}\n`;139    };140141    // Rewrite the highlighted code block in place, keeping Shiki's tokens.142    // Values are declared once, on the root element, so a text-node swap is exact.143    const rewriteCodeDom = (name: string, oldVal: string, newVal: string) => {144      if (!codeEl || oldVal === newVal) return;145      const walker = document.createTreeWalker(codeEl, NodeFilter.SHOW_TEXT);146      const declRe = new RegExp(`(${esc(name)}\\s*:\\s*)${esc(oldVal)}`, 'g');147      let hit = false;148      for (let node = walker.nextNode(); node; node = walker.nextNode()) {149        const text = node.nodeValue ?? '';150        if (declRe.test(text)) {151          node.nodeValue = text.replace(declRe, `$1${newVal}`);152          declRe.lastIndex = 0;153          hit = true;154        }155      }156      if (hit) return;157      // Fallback if the tokenizer split "name:" and "value" apart.158      const walker2 = document.createTreeWalker(codeEl, NodeFilter.SHOW_TEXT);159      for (let node = walker2.nextNode(); node; node = walker2.nextNode()) {160        const text = node.nodeValue ?? '';161        if (text.includes(oldVal)) {162          node.nodeValue = text.replace(oldVal, newVal);163          return;164        }165      }166    };167168    const applyControl = (input: HTMLInputElement) => {169      const name = input.dataset.var!;170      const value = `${input.value}${input.dataset.unit ?? ''}`;171      const prev = current.get(name)!;172      current.set(name, value);173174      const output = input.closest('.sg-control')?.querySelector('output');175      if (output) output.textContent = value;176177      frame?.contentDocument?.body?.firstElementChild178        ?.getAttribute('style') !== null &&179        (frame?.contentDocument?.body?.firstElementChild as HTMLElement | null)?.style.setProperty(name, value);180181      rewriteCodeDom(name, prev, value);182      rebuildCopyText();183    };184185    const form = document.querySelector<HTMLFormElement>('[data-sg-customizer]');186    form?.addEventListener('input', (e) => {187      if (e.target instanceof HTMLInputElement && e.target.dataset.var) applyControl(e.target);188    });189    form?.addEventListener('reset', () => {190      requestAnimationFrame(() => {191        form.querySelectorAll<HTMLInputElement>('input[data-var]').forEach(applyControl);192      });193    });194  }195</script>196